Skip to content

[feat] Add fastvideo serve support for FastMetal (Wan on Mac/MLX) — 1.3B, 14B, and 5B - #1802

Open
Ishxn20 wants to merge 4 commits into
hao-ai-lab:mainfrom
Ishxn20:feat/wan-mlx-serving
Open

Ishxn20 wants to merge 4 commits into
hao-ai-lab:mainfrom
Ishxn20:feat/wan-mlx-serving

Conversation

@Ishxn20

@Ishxn20 Ishxn20 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  1. The server couldn't run non-Nvidia models at all. Added the ability to plug in a different generator, so MLX (and anything else non-CUDA) can be served through the same system CUDA already uses. This part re-derives the same capability [feat] Add an H3 server cookbook and prompt playground #1798 (H3 MLX serving) already builds — it touches the same 4 files, so whichever PR merges second will need a quick rebase.
  2. Built the actual Mac pipeline for Wan. It didn't exist before — the only Mac version of Wan was a script that ran once and exited, with no way to keep it loaded and serve requests. 1.3B and 14B share one pipeline (same underlying model); 5B is a different, newer architecture, so it gets its own.

Testing

  • 358 automated tests pass — config loading, bad-request rejection, and routing to the right model size.

  • Confirmed the existing Nvidia server path is untouched.

  • Real generation on Apple Silicon (M1 Pro, 16 GB). 1.3B served via
    fastvideo serve, generated end to end on Metal:

    $ curl -s http://127.0.0.1:50001/v1/videos -H 'Content-Type: application/json' \
        -d '{"model":"fastwan21-1.3b-mlx","prompt":"A fox runs through fresh snow.","seconds":1,"size":"256x256"}'
    
    INFO [wan_pipeline.py:301] Wan MLX denoise step 1/3 complete
    INFO [wan_pipeline.py:301] Wan MLX denoise step 2/3 complete
    INFO [wan_pipeline.py:301] Wan MLX denoise step 3/3 complete
    INFO [video_api.py:225] Video video_gen_1adc5e84... completed in 154.52s
    
    $ ffprobe -v error -count_frames -select_streams v:0 \
        -show_entries stream=nb_read_frames,width,height -of default=nw=1 fox.mp4
    width=256
    height=256
    nb_read_frames=17
    
    1s × 16fps = 16 frames, which Wan rejects (needs 1 mod 4); the request
    produced 17. Video attached in the comments.
    
fox.mp4
  • 5B prompt-encoder parity — bit-identical. Compared the server's
    _encode_wan_prompt against the reference script's encode_prompt for the
    same prompt on the 5B recipe (fp16 / CPU):

    server: torch.float16 (1, 512, 4096)
    script: torch.float16 (1, 512, 4096)
    bit-identical: True
    max abs diff: 0.0
    
    Pre-fix the server encoded 5B in bf16 on MPS, losing three mantissa bits
    against the script's fp16.
    
  • 5B (Wan2.2) generation not yet run — needs more unified memory than this
    machine has; a 49-frame 1.3B decode already exhausted 16 GB. Pending a
    larger Mac. (@aryan5v)

Copilot AI lite review requested due to automatic review settings September 1, 2026 03:15
@mergify mergify Bot added scope: inference Inference pipeline, serving, CLI scope: infra CI, tests, Docker, build labels Sep 1, 2026
@mergify

mergify Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

⚠️ PR title format required

Your PR title must start with a type tag in brackets. Examples:

  • [feat] Add new model support
  • [bugfix] Fix VAE tiling corruption
  • [refactor] Restructure training pipeline
  • [perf] Optimize attention kernel
  • [ci] Update test infrastructure
  • [infra] Add activation trace hooks
  • [docs] Add inference guide
  • [misc] Clean up configs
  • [new-model] Port Flux2 to FastVideo
  • [skill] Add add-model agent skill

Valid tags: feat, feature, bugfix, fix, refactor, perf, ci, infra, doc, docs, misc, chore, kernel, new-model, skill, skills

Please update your PR title and the merge protection check will pass automatically.

@mergify

mergify Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🔴 1 of 1 protections blocking · waiting on 👀 reviews and 🤖 CI

Protection Waiting on
🔴 PR merge requirements 👀 reviews and 🤖 CI

🔴 PR merge requirements

Waiting for

  • #approved-reviews-by>=1
  • check-success=full-suite-passed
This rule is failing.
  • #approved-reviews-by>=1
  • check-success=full-suite-passed
  • check-success=fastcheck-passed
  • check-success~=pre-commit
  • title~=(?i)^\[(feat|feature|bugfix|fix|refactor|perf|ci|doc|docs|misc|chore|kernel|new.?model|skill|skills|infra)\]

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are correctness issues in the MLX request validation path (task handling) and the PR also introduces a new serving/runtime surface area that still needs real-hardware validation before it’s safe to merge.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR extends FastVideo’s OpenAI-compatible serving stack to support a non-CUDA runtime (MLX) by allowing the server to plug in an alternate generator implementation and runtime-specific request validation, then adds a native MLX “FastMetal Wan” server/pipeline for 1.3B, 14B, and 5B.

Changes:

  • Add a pluggable generator_factory + per-runtime video_request_validator to the shared OpenAI server app/engine so non-CUDA backends can be served.
  • Introduce MLX Wan pipelines (Wan2.1 for 1.3B/14B and Wan2.2-TI2V for 5B) plus a dedicated MLX Wan server entrypoint.
  • Add tests and example YAML configs covering config parsing, validation, and generator dispatch for all three model sizes.
File summaries
File Description
fastvideo/entrypoints/openai/api_server.py Adds runtime/generator factory hooks and conditionally disables image routes for MLX runtime.
fastvideo/entrypoints/openai/serving_engine.py Generalizes generator shape via ServingGenerator and adds runtime-specific request validation hook.
fastvideo/entrypoints/openai/state.py Updates global generator typing to the new serving generator protocol.
fastvideo/entrypoints/openai/video_api.py Runs runtime-specific request validation before CUDA-oriented model/LoRA validation.
fastvideo/entrypoints/openai/mlx_wan_server.py New MLX Wan server entrypoint, config parsing, request allowlist validation, and MLX generator implementation.
fastvideo/mlx_runtime/wan_pipeline.py New MLX Wan2.1 and Wan2.2-TI2V pipelines and shared prompt/rope helpers for repeated server calls.
fastvideo/tests/entrypoints/test_mlx_wan_server.py Tests MLX Wan server config parsing, allowlist validation, and generator dispatch/routing.
fastvideo/tests/mlx/test_mlx_wan_pipeline.py Tests MLX Wan pipeline constructors’ filesystem + checkpoint-shape validation.
examples/serving/mlx_wan21_1_3b.yaml Example serving config for FastMetal Wan2.1 1.3B.
examples/serving/mlx_wan21_14b.yaml Example serving config for FastMetal Wan2.1 14B.
examples/serving/mlx_wan22_5b.yaml Example serving config for FastMetal Wan2.2-TI2V 5B.
Review details
  • Files reviewed: 11/11 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +89 to +90
if request.task not in (None, "t2v"):
raise ValueError("Wan MLX serving supports task=t2v only.")
Comment thread fastvideo/mlx_runtime/wan_pipeline.py Outdated
Comment on lines +156 to +162
try:
manifest = json.loads(manifest_path.read_text())
except (json.JSONDecodeError, OSError):
return None
config = manifest.get("config", manifest)
channels = config.get("in_channels")
return int(channels) if channels is not None else None
Comment on lines +27 to 30
def get_generator() -> ServingGenerator:
"""Return the global VideoGenerator instance (set during startup)."""
assert _generator is not None, "Server not initialized — generator is None"
return _generator
@SolitaryThinker

Copy link
Copy Markdown
Collaborator

Rebased onto main to pick up #1798’s shared MLX serving infrastructure; no content changes (range-diff clean).

"height",
"fps",
"num_frames",
"seconds",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Align or reject seconds before admitting the job. The shared adapter turns an explicit seconds into num_frames = seconds * fps; with both shipped defaults (16 and 24 fps), that is always 0 mod 4, while plan_refine_resolutions requires Wan frames to be 1 mod 4. A normal OpenAI-style request therefore gets queued and fails only inside generation. Please validate the merged request shape synchronously and either map duration to the nearest valid frame grid (for example seconds * fps + 1) or do not advertise seconds for this runtime.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 11f0c76. validate_wan_video_request now resolves an explicit seconds to a Wan-legal num_frames before the job is admitted, rounding up to the next value that is 1 mod the VAE temporal stride. It mirrors the adapter's explicit-field precedence, including the nested video_params spelling, and leaves an explicit num_frames untouched. create_mlx_wan_app binds the served fps into the validator so alignment uses the config's fps rather than the adapter's generic 24 fallback.

Verified on an M1 Pro: {"seconds": 1, "size": "256x256"} produced 17 frames (ffprobe nb_read_frames=17) where the naive 1×16 = 16 would have been rejected. A {"seconds": 3} request at 480×832 resolved to 49 frames and cleared plan_refine_resolutions plus all three denoise steps; it then hit a Metal OOM in TAEHV decode, which is a memory limit on this 16 GB machine rather than the admission path. Full logs in the comment below.

Comment thread fastvideo/mlx_runtime/wan_pipeline.py Outdated
tokenizer = AutoTokenizer.from_pretrained(model_root / "tokenizer", local_files_only=True)
text_encoder = UMT5EncoderModel.from_pretrained(
model_root / "text_encoder",
torch_dtype=torch.bfloat16,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep the 5B prompt-encoder path on the recipe-validated dtype/device, or prove the new path on hardware. mlx_wan22_generate.py deliberately encodes 5B prompts in FP16 on CPU, but this shared helper forces BF16 on MPS and only casts the already-rounded embeddings back to FP16 afterward. That is not the same math (BF16 loses three mantissa bits), and this PR has no real-Mac generation/parity run to show the 5B output remains valid. Please parameterize dtype/device by model family and retain the 5B FP16 path, with an actual FastMetal-5B smoke/parity result.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 11f0c76. _encode_wan_prompt now takes device_arg and dtype_arg, defaulting to Wan2.1's recipe (bf16 / auto, matching mlx_wan_prompt_to_video.py). MLXWan22Pipeline passes cpu / fp16, so 5B is back on the path mlx_wan22_generate.py validated. The bf16 widening before the NumPy hand-off is now conditional, since fp16 maps directly.

Parity result on an M1 Pro — server path vs. the reference script, same prompt, both on the 5B recipe:
server: torch.float16 (1, 512, 4096)
script: torch.float16 (1, 512, 4096)
bit-identical: True
max abs diff: 0.0

@SolitaryThinker SolitaryThinker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for the two inline blockers and the still-open task-validation mismatch:

  • An explicit seconds request is admitted but the shared adapter produces a frame count that violates the Wan 1-mod-4 temporal grid under both shipped FPS defaults.
  • The 5B server changes the maintained FP16/CPU prompt-encoder path to BF16/MPS without real-hardware parity evidence.
  • validate_wan_video_request accepts task=t2v, but the shared request adapter rejects every non-None task for non-MiniMax models, as the existing inline review notes.

Please also address the malformed-manifest validation comment, run at least one real Apple-Silicon generation for each distinct pipeline (Wan2.1 and Wan2.2), and prefix the PR title with [feat] so merge protection can pass. Changed-file pre-commit is green on the rebased head; the focused pytest collection is not runnable on this Linux host because package import initializes Triton without an active GPU driver.

@Ishxn20 Ishxn20 changed the title Adds fastvideo serve support for FastMetal (Wan on Mac/MLX) — all three sizes: 1.3B, 14B, and 5B. [feat] Add fastvideo serve support for FastMetal (Wan on Mac/MLX) — 1.3B, 14B, and 5B Sep 7, 2026
@mergify mergify Bot added the type: feat New feature or capability label Sep 7, 2026

@SolitaryThinker SolitaryThinker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review — correctness / efficiency / simplification

Reviewed head 11f0c76b8. CI is green; the standing change request is unchanged. Findings ordered by severity, with file:line evidence.

Blocking

1. Wan2.2 (5B) has no end-to-end hardware evidence.
fastvideo/mlx_runtime/wan_pipeline.py:352 (MLXWan22Pipeline.generate), examples/serving/mlx_wan22_5b.yaml. The PR body still marks "5B (Wan2.2) generation not yet run", and the only 5B evidence is a prompt-encoder parity check. The 48-channel DiT load, sample_wan22_dmd (flow_shift 5.0, renoise seed 0), and z_dim=48 TAEHV decode have never executed. This is the outstanding item from the previous review; it needs Apple Silicon hardware with more memory than the author's 16 GB M1 Pro.

Major

2. Grid-invalid explicit geometry is admitted, then fails inside generation after a full UMT5 encode.
fastvideo/entrypoints/openai/mlx_wan_server.py:115 (validate_wan_video_request) does not check num_frames % 4 == 1 or width/height divisibility; wan_pipeline.py:241/:380 encode the prompt before plan_refine_resolutions at :246/:387. Verified locally: num_frames=80 and size=833x481 both pass validation, then fail inside generation (async: generation_failed; /v1/videos/sync: HTTP 500) after paying the ~45 s UMT5 load/encode. This is the synchronous-rejection contract the validator docstring and the prior P1 asked for.

3. fastvideo serve --config cannot load the shipped MLX YAMLs.
Verified: build_serve_config on examples/serving/mlx_wan21_1_3b.yaml raises ConfigValidationError: runtime: unknown field (fastvideo/api/schema.py:269 ServeConfig has no runtime; GeneratorConfig has no model_root/mlx_checkpoint). The real entrypoint is python -m fastvideo.entrypoints.openai.mlx_wan_server --config ... (consistent with H3's mlx_server), but the title, the body ("1.3B served via fastvideo serve"), and examples/serving/README.md:40-45 never say so.

4. Prompt cache is bypassed; every request reloads UMT5 and re-encodes.
wan_pipeline.py:87 (_encode_wan_prompt) never calls load_prompt_cache/save_prompt_cache, and mlx_wan_server.py:47-52 has no cache field. The H3 server wires prompt_cache_dir (mlx_server.py:32, minimax_h3_pipeline.py:401), and both CLI recipes use the cache by default. The repo's own comment (examples/inference/basic/mlx_wan22_generate.py:109) measures a full UMT5 encode at ~45 s on an M4 Max — paid on every request, including identical playground re-runs.

5. Playground claim is false for Wan.
mlx_wan_server.py:2 says "through the shared video-job API and playground", but fastvideo/entrypoints/openai/playground.py:25-33 (require_h3) 404s every non-H3 family. /playground/ is dead for FastMetal.

6. Unregistered FastMetal paths cause HF Hub lookups per request and in unit tests.
request_adapter.py:339 / api/sampling_param.py:212registry.py:216hf_hub_download. Verified: build_generation_request issued HEAD+GET for FastVideo/FastMetal-1.3B-QAD/model_index.json during a local request. FastMetal has no registry.py entry (unlike FastH3), so ~3 lookups per request plus 2 at startup; offline machines stall on retry timeouts before falling back to SamplingParam().

7. _align_seconds_to_frame_grid diverges from the adapter on fps: null + video_params.fps.
mlx_wan_server.py:86-112 vs request_adapter.py:300-317. Verified: with seconds=1, fps=None, video_params={"fps": 16}, the validator resolves num_frames=17 from fps 16, while the adapter then uses its 24 fallback → 17 frames at 24 fps. Admission and generation disagree on the same request.

8. No test exercises either generate() orchestration path.
fastvideo/tests/mlx/test_mlx_wan_pipeline.py covers only __init__ filesystem/architecture guards; test_mlx_wan_server.py:239-336 stubs the pipeline class. The per-family contracts this PR introduces (bf16/MPS vs fp16/CPU, flow_shift 8.0 vs 5.0, DMD ladder, renoise seed 0, z_dim 16 vs 48) are unverified.

Minor

  • wan_pipeline.py:292-293: latents.astype(mx.float32) computed twice per step; dmd_step discards the latents arg (sampling.py:131) — one wasted full-tensor cast/step.
  • mlx_wan_server.py:27,31 duplicate _DEFAULT_DMD_STEPS/temporal-stride constants with nothing linking them.
  • wan_pipeline.py:61 calls mx.get_peak_memory() unguarded; H3 guards with getattr (minimax_h3_pipeline.py:206).
  • wan_pipeline.py:54 defines a third GenerationResult, shadowing the one exported from fastvideo/mlx_runtime/__init__.py:71.
  • mlx_wan_server.py shares 140 identical lines with mlx_server.py; _encode_wan_prompt/_make_wan_rotary_embeddings copy the CLI helpers, contradicting the module docstring's "cannot silently drift" claim.
  • Stale comments: api_server.py:169-170 ("video-with-audio" is H3-only), wan_pipeline.py:69 device docstring, mlx_wan_server.py:57-60 self-aware TODO.
  • examples/serving/README.md:40-45 documents only mlx_server; the three new configs are undiscoverable.
  • _make_wan_rotary_embeddings (wan_pipeline.py:152) indexes config["patch_size"] directly (bare KeyError; CLI helper does the same).
  • DiT reload + mx.compile re-trace per request and timings/peak_memory_gib computed then dropped — both deliberate and matching H3's phase-memory policy.

Verified correct

  • 5B encoder is back on the recipe-validated fp16/CPU path; 1.3B/14B stay bf16/auto (wan_pipeline.py:379-382).
  • seconds alignment mirrors the adapter's explicit-field precedence for the common spellings; task=t2v is normalized before the adapter rejects it.
  • _packed_dit_channels (wan_pipeline.py:170-194) handles malformed manifests (the earlier Copilot comment).
  • Model→pipeline dispatch, non-Apple/ffmpeg rejection, worker-thread lifecycle, and __init__ architecture guards are covered.
  • Shared serving files changed only in comments/docstrings; the runtime != "mlx" image-router exclusion was already on main.
  • CI coverage: test_mlx_wan_pipeline.py is in both macOS jobs; test_mlx_wan_server.py runs in the Buildkite unit lane.

Could not verify

Real Apple Silicon generation (no Mac/MLX here), the 5B DiT/sampler/decode path, and whether mx.get_peak_memory is guaranteed by the pinned MLX floor.

cc @aryan5v for the 5B hardware validation.

@aryan5v
aryan5v self-requested a review September 15, 2026 03:38
@aryan5v

aryan5v commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Rebased locally onto current main (4 commits, clean). On an M4 Max, test_mlx_wan_server.py + test_mlx_wan_pipeline.py are 64/64 passing.

Could not complete 5B (Wan2.2) generation here. FastVideo/FastMetal-5B-QAD is not on disk, and Hub downloads of the packed DiT + UMT5 (~16 GB without the unused VAE) kept stalling. The standing “5B not run on hardware” item is still open.

Not merge-ready. Two things I re-checked and would not ship without:

  1. Invalid explicit geometry is still admitted. num_frames=80 and size=833x481 pass validate_wan_video_request, then die in plan_refine_resolutions after UMT5 encode. Reject that at admission (family-specific spatial stride: 8 vs 16).
  2. Unregistered FastMetal IDs do not just miss the registry. get_preset_selection("FastVideo/FastMetal-5B-QAD") fetches Hub model_index.json (_class_name: WanDMDPipeline) and returns ('fast_wan_t2v_480p', 'wan') — the Wan2.1 1.3B/14B preset. Every /v1/videos request pays that lookup. Register the three FastMetal IDs, or skip Hub on the MLX path.

Seconds → frame-grid alignment does survive into build_generation_request when the validator assigns num_frames. I am not restating the rest of the prior review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: inference Inference pipeline, serving, CLI scope: infra CI, tests, Docker, build type: feat New feature or capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants